Skip to content

[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload#33

Open
AbanoubGhadban wants to merge 1 commit intomainfrom
perf/rsc-revive-model-walk
Open

[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload#33
AbanoubGhadban wants to merge 1 commit intomainfrom
perf/rsc-revive-model-walk

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

@AbanoubGhadban AbanoubGhadban commented Apr 18, 2026

Summary

Closes #34

Backport of facebook/react#35776 — ~75% speedup in RSC chunk deserialization.

Rebuilt react-server-dom-webpack from React fork rsc-patches/v19.0.3 with the optimization applied (fork PR).

The optimization

Replaces JSON.parse(json, reviver) with a two-step approach:

  1. JSON.parse(json) — runs entirely in V8's C++ engine, no callbacks
  2. reviveModel() — recursive pure-JS walk that applies RSC transformations

The reviver callback was the bottleneck: V8's JSON.parse is C++, and calling back into JS for every key-value pair incurs ~4x overhead even with a no-op reviver.

Benchmark results (from upstream PR)

Payload Speedup
Small (142B) +72%
Medium (914B) +73%
Large (16.7KB) +75%
XL (25.7KB) +76%
Table (1000 rows, 110KB) +78%

Additional changes in this rebuild

The plugin file also picks up the compiled form of [RSC-PATCH] Fix client manifest chunk scan when non-JS assets precede JS from source (previously patched manually on main via #23).

Test plan

  • Verify RSC payload parsing works correctly
  • Test with nested Suspense boundaries
  • Test with client/server component mix
  • Benchmark deserialization on a real app

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Improved internal data deserialization process for React Server Components across multiple runtime environments (browser, edge, Node.js).
    • Refined chunk file selection logic in the build plugin to correctly exclude hot-update files.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

Walkthrough

The pull request refactors the JSON parsing mechanism across multiple React Server DOM webpack client modules, replacing inline JSON.parse reviver callbacks with explicit two-step parseModel and reviveModel functions. A minor improvement to chunk file selection logic in the webpack plugin is also included.

Changes

Cohort / File(s) Summary
Client Module Parsing Refactor (Browser/Edge/Node variants)
react-server-dom-webpack-client.browser.development.js, react-server-dom-webpack-client.browser.production.js, react-server-dom-webpack-client.edge.development.js, react-server-dom-webpack-client.edge.production.js, react-server-dom-webpack-client.node.development.js, react-server-dom-webpack-client.node.production.js, react-server-dom-webpack-client.node.unbundled.development.js, react-server-dom-webpack-client.node.unbundled.production.js
Replaced JSON.parse reviver callback pattern with explicit parseModel(response, json) and reviveModel(response, value, parentObject, key) functions. Removed ResponseInstance._fromJSON field initialization and createFromJSONCallback function. Updated all model parsing call sites (module resolution, hint handling, console replay, chunk initialization) to use the new two-step parsing approach. Revival logic now handles React element tuples, lazy chunk promises, prototype sanitization, and recursive object/array traversal in a centralized location.
Plugin Chunk Selection
react-server-dom-webpack-plugin.js
Refined chunk file iteration logic to properly continue through non-matching entries and only break after pushing a valid .js file that is not a .hot-update.js file, ensuring correct chunk population.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Whiskers twitching with delight,
Revamped our parsing—clean and bright!
No more callbacks hiding in the weeds,
Two-step model revival serves all needs!
Organized as a burrow, deep and true,

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main technical change: replacing a reviver-based JSON parsing approach with a walk-based alternative for RSC payload parsing.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/rsc-revive-model-walk

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

for (var i = 0; i < value.length; i++)
value[i] = reviveModel(response, value[i], value, "" + i);
if (value[0] === REACT_ELEMENT_TYPE) {
if (value[0] === REACT_ELEMENT_TYPE)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant check (compiler artifact) — the outer if at line 1461 already guarantees value[0] === REACT_ELEMENT_TYPE is true here, making this inner check unreachable-false. It's a no-op, but dead conditionals are confusing and could mislead future readers into thinking the else branch (line 1489 response = value) is reachable when the inner check fails — it isn't.

This appears identically in all 8 compiled files. It looks like a decompiler/compiler artifact in the upstream fork's output (rsc-patches/v19.0.3). Worth filing upstream or patching out of the compiled output to keep the code clean.

Suggested change
if (value[0] === REACT_ELEMENT_TYPE)
if (value[0] === REACT_ELEMENT_TYPE)
b: {

(i.e. remove the redundant outer if wrapper and fold the labeled block up one level)

for (i in value)
"__proto__" === i
? delete value[i]
: ((parentObject = reviveModel(response, value[i], value, i)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parentObject parameter is clobbered — on the first loop iteration, parentObject is overwritten with the return value of reviveModel(), destroying the reference to the actual parent. Subsequent recursive calls still pass value (the current object) as parentObject correctly, so this doesn't affect correctness. But during debugging, inspecting parentObject mid-loop gives a misleading value.

Same pattern exists in all 8 compiled files — it's a minifier artifact (variable reuse). A simple rename to revived for the temp would make intent clear, but since this is compiled output that's a note for the upstream source rather than a direct edit here.

if (file.endsWith(".hot-update.js")) break;
chunks.push(c.id, file);
break;
if (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix. The original code used three sequential break statements that exited the for loop unconditionally on the first file, even if it wasn't a .js file. The new if condition lets the loop continue past non-JS (e.g. CSS, image) assets until it finds a qualifying .js file. This was a real bug: in any chunk that emits a non-JS asset before its JS file, the manifest would silently record the wrong file or nothing at all.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 18, 2026

Code Review

Overview

This PR backports facebook/react#35776 — replacing JSON.parse(json, reviver) with a two-step JSON.parse(json) + reviveModel() walk across all 8 compiled client bundles, plus a bug fix to the webpack plugin's chunk-file scan. The performance motivation is sound: V8's JSON.parse reviver triggers a C++→JS boundary call for every key-value pair, which dominates cost on large RSC payloads.


Performance

The benchmark numbers in the PR description are credible and match the upstream claim. The two-pass approach (full C++ parse → pure-JS walk) eliminates the per-node callback overhead that was the bottleneck. The only new allocation is the { "": json } sentinel object created per parseModel() call, which is negligible.


Correctness

Traversal order is preserved. The original reviver ran bottom-up (children before parents). The new reviveModel processes array children in a forward loop first, then constructs the React element from the already-revived values — semantically equivalent.

undefined-deletion semantics are preserved. The object-walk branch:

void 0 !== parentObject ? (value[i] = parentObject) : delete value[i]

correctly replicates the reviver's behavior of deleting properties whose reviver returned undefined.

response variable aliasing — in the array branch, the response parameter is overwritten to hold the return value (a chunk wrapper or the element itself). This is a minifier artifact and doesn't affect correctness; the actual response context object is no longer needed in that branch after that point. Noted with an inline comment.

Redundant double-checkif (value[0] === REACT_ELEMENT_TYPE) appears twice consecutively in every file. Noted with an inline comment; the dead else branch is unreachable. This is a compiler artifact from the upstream fork.


Security

  • __proto__ injection — the object-walk branch explicitly checks "__proto__" === i and deletes it before recursing. This guards against prototype pollution via crafted RSC payloads. ✅
  • Two-pass parsing riskJSON.parse(json) without a reviver runs entirely in V8 C++ and cannot produce prototype-polluted objects in modern engines. The subsequent walk then handles any $-prefixed RSC references. No new attack surface versus the original approach.

Issues

  1. Compiled-only files, no source. This PR contains only the compiled .js output from the upstream React fork. There are no TypeScript/JSX source files to audit, and no way to verify the compiled output matches what the fork's source actually produces. The PR description links to the fork but it would be worth confirming the compiled files were produced from the stated commit (rsc-patches/v19.0.3) via a reproducible build step, ideally documented in the repo.

  2. Redundant double-check in all 8 files. See inline comment on browser.production.js:1462. This is the same in all bundles. Consider filing upstream or patching it out.

  3. Test plan is entirely unchecked. All four items in the test plan remain open. Before merging, at minimum:

    • RSC payload parsing round-trip (including $-prefixed references, lazy chunks, and nested Suspense boundaries)
    • The plugin fix path (a chunk group that contains a non-JS asset before the JS file)
  4. Duplicate changes across 8 files. The same reviveModel logic is copy-pasted into 8 files. Future changes will require the same edit in 8 places. This is an inherent property of vendoring compiled React bundles, but worth documenting so maintainers know to update all files together.


Plugin Fix

The webpack-plugin.js change is correct — see inline comment. The original break-based logic exited the for loop on the first file regardless of its extension, silently skipping CSS/image assets. The fix is the right approach.


Summary

The optimization is legitimate and the implementation is correct. The main concerns before merging are: (1) confirming the compiled output is reproducible from the stated fork commit, and (2) running the test plan — particularly round-trip parsing with lazy chunk references and the plugin fix with mixed-asset chunk groups.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 18, 2026

Greptile Summary

This PR backports facebook/react#35776, replacing JSON.parse(json, reviver) with a two-step JSON.parse + recursive reviveModel walk across all 8 environment builds, yielding ~75% deserialization speedup. It also picks up the compiled fix for non-JS assets preceding JS assets in the plugin's chunk manifest scan (previously patched manually in #23).

Confidence Score: 5/5

Safe to merge — all findings are P2 style observations about transpilation artifacts with no functional impact.

The core optimization is a faithful backport of a well-reviewed upstream React PR. The reviveModel traversal preserves the same initializingHandler push/pop ordering as the original reviver. The plugin fix correctly changes break-on-no-match to continue-on-no-match. The only observation is a redundant double REACT_ELEMENT_TYPE guard that is a transpilation artifact with no runtime effect.

No files require special attention.

Important Files Changed

Filename Overview
src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js Fixes chunk manifest scan bug: old code broke out of the file loop on any non-JS file, now correctly continues iterating to find the first valid .js (non-hot-update) file per chunk.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js Same reviveModel optimization as dev build; contains redundant double REACT_ELEMENT_TYPE guard (transpilation artifact), no functional impact.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js Replaces JSON.parse reviver with two-phase parseModel+reviveModel walk; logic is consistent with other env builds and the upstream React PR.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js Identical reviveModel optimization applied for edge runtime; consistent with browser/node builds.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js Edge production build with same two-phase parse optimization; no issues found.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js Node development build with reviveModel optimization; consistent implementation.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js Node production build with reviveModel optimization; consistent implementation.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js Node unbundled development build; reviveModel applied consistently.
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.production.js Node unbundled production build; reviveModel applied consistently.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["parseModel(response, json)"] --> B["JSON.parse(json)\nPure C++ — no JS callbacks"]
    B --> C["reviveModel(response, parsedValue, root, '')"]
    C --> D{typeof value?}
    D -- string --> E{"starts with '$'?"}
    E -- yes --> F["parseModelString()\nResolve RSC tokens\n($, $L, $@, $S, ...)"]
    E -- no --> G["Return string as-is"]
    D -- "non-object / null" --> H["Return primitive"]
    D -- array --> I["Recursively reviveModel\neach element in order"]
    I --> J{"value[0] ===\nREACT_ELEMENT_TYPE?"}
    J -- yes --> K["Build React element object\nHandle initializingHandler push/pop"]
    J -- no --> L["Return array"]
    D -- object --> M["for (i in value)\nRecursively reviveModel each property"]
    M --> N{"key === '__proto__'?"}
    N -- yes --> O["delete value[i] (proto-pollution guard)"]
    N -- no --> P{"result === undefined?"}
    P -- yes --> Q["delete value[i]"]
    P -- no --> R["value[i] = result"]
Loading

Reviews (1): Last reviewed commit: "[RSC-PATCH] Walk parsed JSON instead of ..." | Re-trigger Greptile

Comment on lines +1461 to +1489
if (value[0] === REACT_ELEMENT_TYPE) {
if (value[0] === REACT_ELEMENT_TYPE)
b: {
value = {
$$typeof: REACT_ELEMENT_TYPE,
type: value[1],
key: value[2],
ref: null,
props: value[3]
}),
null !== initializingHandler)
)
if (
((value = initializingHandler),
(initializingHandler = value.parent),
value.errored)
)
(key = new ReactPromise("rejected", null, value.value, response)),
(key = createLazyChunkWrapper(key));
else if (0 < value.deps) {
var blockedChunk = new ReactPromise(
"blocked",
null,
null,
response
);
value.value = key;
value.chunk = blockedChunk;
key = createLazyChunkWrapper(blockedChunk);
};
if (null !== initializingHandler) {
i = initializingHandler;
initializingHandler = i.parent;
if (i.errored) {
response = new ReactPromise("rejected", null, i.value, response);
response = createLazyChunkWrapper(response);
break b;
}
if (0 < i.deps) {
response = new ReactPromise("blocked", null, null, response);
i.value = value;
i.chunk = response;
response = createLazyChunkWrapper(response);
break b;
}
}
} else key = value;
return key;
response = value;
}
else response = value;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant REACT_ELEMENT_TYPE double-check

The inner if (value[0] === REACT_ELEMENT_TYPE) guard (line 1462) is always true when reached — it is nested directly inside the identical outer check on line 1461. The outer check already guarantees the condition holds. The labeled break b escapes from the inner block but not any outer guard, so the else response = value branch on line 1489 is dead code. This appears to be a transpilation artifact from the upstream fork, but it may confuse future readers who try to understand when the else branch executes.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js (1)

1671-1681: ⚠️ Potential issue | 🔴 Critical

Add the no-op setter for $Y omitted-property descriptors.

Line 1673 defines a getter-only, non-configurable property, then Line 2411 writes the revived null back to the same key. In strict mode this matches the failing Cannot set property asyncQueue ... which has only a getter pipeline error. Add the no-op setter from the upstream follow-up so the explicit walk preserves the old reviver behavior: https://github.com/facebook/react/pull/35776/files

🐛 Proposed fix
             Object.defineProperty(parentObject, key, {
               get: function () {
                 return "This object has been omitted by React in the console log to avoid sending too much data from the server. Try logging smaller or more specific objects.";
               },
+              set: function () {},
               enumerable: !0,
               configurable: !1
             }),

Also applies to: 2406-2412

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js`
around lines 1671 - 1681, The getter-only omitted-property descriptor in the
switch case "Y" creates a non-configurable, getter-only property on
parentObject[key], causing a strict-mode "Cannot set property ... which has only
a getter" when code later writes to that key; fix it by adding a no-op setter
(set: function() {}) to the Object.defineProperty call in the case "Y" branch so
the property supports assignments while preserving enumerable and configurable
values, and apply the identical change to the other occurrence around the
revived null write (the block around the 2406–2412 region) to keep reviver
behavior consistent.
♻️ Duplicate comments (1)
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js (1)

2340-2412: ⚠️ Potential issue | 🟠 Major

Apply the same reviver-semantics guard in the edge dev bundle.

This manual walk has the same failure mode as the browser dev build: $Y can replace a property with a getter-only accessor before Lines 2341-2342 or Lines 2409-2412 assign back to it, throwing under "use strict". The for...in object walk should also be own-property-only.

Suggested guard for this generated variant
     function parseModel(response, json) {
       json = JSON.parse(json);
       return reviveModel(response, json, { "": json }, "");
     }
+    function setRevivedProperty(target, key, revivedValue) {
+      var descriptor = Object.getOwnPropertyDescriptor(target, key);
+      if (!descriptor || descriptor.writable || descriptor.set)
+        target[key] = revivedValue;
+    }
     function reviveModel(response, value, parentObject, key) {
@@
       if ("object" !== typeof value || null === value) return value;
       if (isArrayImpl(value)) {
         for (var i = 0; i < value.length; i++)
-          value[i] = reviveModel(response, value[i], value, "" + i);
+          setRevivedProperty(
+            value,
+            "" + i,
+            reviveModel(response, value[i], value, "" + i)
+          );
@@
-      for (i in value)
-        "__proto__" === i
-          ? delete value[i]
-          : ((parentObject = reviveModel(response, value[i], value, i)),
-            void 0 !== parentObject
-              ? (value[i] = parentObject)
-              : delete value[i]);
+      for (i in value)
+        if (hasOwnProperty.call(value, i))
+          "__proto__" === i
+            ? delete value[i]
+            : ((parentObject = reviveModel(response, value[i], value, i)),
+              void 0 !== parentObject
+                ? setRevivedProperty(value, i, parentObject)
+                : delete value[i]);

Use the same read-only verification snippet from the browser development bundle comment; it reproduces both JavaScript behaviors independently of the repository files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js`
around lines 2340 - 2412, The for...in walk in reviveModel can throw when a
serialized $Y injects a getter-only accessor; update the loop that iterates
properties of value (inside function reviveModel) to only operate on own,
writable data properties: for each key i, first skip if
!Object.prototype.hasOwnProperty.call(value, i); then read the descriptor via
Object.getOwnPropertyDescriptor(value, i) and if it is an accessor without a
setter or a non-writable data descriptor, do not attempt to assign back into
value (delete or skip), otherwise proceed to call reviveModel and assign the
result; apply this same read-only verification logic to both the array/object
branches where properties are assigned so the edge dev bundle matches the
browser dev guard.
🧹 Nitpick comments (1)
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js (1)

2369-2376: Avoid shadowing the parentObject parameter in the for-in loop.

Line 2372 reuses parentObject (a function parameter) as the loop variable to store revived property values, which shadows the parameter and reduces readability.

♻️ Use a distinct variable name for clarity
     for (i in value)
       "__proto__" === i
         ? delete value[i]
-        : ((parentObject = reviveModel(response, value[i], value, i)),
-           void 0 !== parentObject
-             ? (value[i] = parentObject)
+        : ((revivedValue = reviveModel(response, value[i], value, i)),
+           void 0 !== revivedValue
+             ? (value[i] = revivedValue)
              : delete value[i]);

Declare revivedValue at the top of reviveModel:

 function reviveModel(response, value, parentObject, key) {
+  var i, revivedValue;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js`
around lines 2369 - 2376, The function reviveModel currently reassigns the
parameter named parentObject inside its for-in loop, shadowing the parameter and
harming clarity; fix it by introducing a new local variable (e.g., revivedValue)
declared near the top of reviveModel and use that variable to store the result
of reviveModel(response, value[i], value, i) and to decide whether to set or
delete value[i], rather than assigning to the parentObject parameter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js`:
- Around line 1660-1664: The loop that iterates properties in reviveModel
currently uses "for (i in value)" which can include inherited properties; update
that loop to skip non-own properties by checking hasOwnProperty.call(value, i)
before processing each key (use the existing module-level hasOwnProperty).
Ensure the existing logic (skipping "__proto__", calling reviveModel and
assigning or deleting parentObject) only runs when hasOwnProperty.call(value, i)
is true, so inherited prototype properties are not revived into the model.

In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js`:
- Around line 2306-2365: Remove the redundant nested check "if (value[0] ===
REACT_ELEMENT_TYPE)" inside the outer guard and lift its block up so the
element-unwrapping logic executes directly when the outer condition is true;
delete the unreachable else branch that returns value (the branch around Line
2364), ensuring the existing behavior that prepares the React element object
(uses REACT_ELEMENT_TYPE, initializingHandler, ReactPromise,
createLazyChunkWrapper, getComponentNameFromType) and then returns response is
preserved exactly.

---

Outside diff comments:
In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js`:
- Around line 1671-1681: The getter-only omitted-property descriptor in the
switch case "Y" creates a non-configurable, getter-only property on
parentObject[key], causing a strict-mode "Cannot set property ... which has only
a getter" when code later writes to that key; fix it by adding a no-op setter
(set: function() {}) to the Object.defineProperty call in the case "Y" branch so
the property supports assignments while preserving enumerable and configurable
values, and apply the identical change to the other occurrence around the
revived null write (the block around the 2406–2412 region) to keep reviver
behavior consistent.

---

Duplicate comments:
In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js`:
- Around line 2340-2412: The for...in walk in reviveModel can throw when a
serialized $Y injects a getter-only accessor; update the loop that iterates
properties of value (inside function reviveModel) to only operate on own,
writable data properties: for each key i, first skip if
!Object.prototype.hasOwnProperty.call(value, i); then read the descriptor via
Object.getOwnPropertyDescriptor(value, i) and if it is an accessor without a
setter or a non-writable data descriptor, do not attempt to assign back into
value (delete or skip), otherwise proceed to call reviveModel and assign the
result; apply this same read-only verification logic to both the array/object
branches where properties are assigned so the edge dev bundle matches the
browser dev guard.

---

Nitpick comments:
In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js`:
- Around line 2369-2376: The function reviveModel currently reassigns the
parameter named parentObject inside its for-in loop, shadowing the parameter and
harming clarity; fix it by introducing a new local variable (e.g., revivedValue)
declared near the top of reviveModel and use that variable to store the result
of reviveModel(response, value[i], value, i) and to decide whether to set or
delete value[i], rather than assigning to the parentObject parameter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b241740b-9ba0-4989-99c1-61b6bb04e6b6

📥 Commits

Reviewing files that changed from the base of the PR and between aba1d26 and aefcbd7.

📒 Files selected for processing (9)
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js

Comment on lines +2131 to +2203
if (isArrayImpl(value)) {
for (var i = 0; i < value.length; i++)
value[i] = reviveModel(response, value[i], value, "" + i);
if (value[0] === REACT_ELEMENT_TYPE) {
if (value[0] === REACT_ELEMENT_TYPE)
if (
((key = value[4]),
(value = {
b: {
i = value[4];
value = {
$$typeof: REACT_ELEMENT_TYPE,
type: value[1],
key: value[2],
props: value[3],
_owner: null === key ? response._debugRootOwner : key
}),
_owner: null === i ? response._debugRootOwner : i
};
Object.defineProperty(value, "ref", {
enumerable: !1,
get: nullRefGetter
}),
(value._store = {}),
});
value._store = {};
Object.defineProperty(value._store, "validated", {
configurable: !1,
enumerable: !1,
writable: !0,
value: 1
}),
});
Object.defineProperty(value, "_debugInfo", {
configurable: !1,
enumerable: !1,
writable: !0,
value: null
}),
null !== initializingHandler)
) {
var handler = initializingHandler;
initializingHandler = handler.parent;
handler.errored
? ((key = new ReactPromise(
});
if (null !== initializingHandler) {
i = initializingHandler;
initializingHandler = i.parent;
if (i.errored) {
response = new ReactPromise(
"rejected",
null,
handler.value,
i.value,
response
)),
(value = {
);
value = {
name: getComponentNameFromType(value.type) || "",
owner: value._owner
}),
(key._debugInfo = [value]),
(value = createLazyChunkWrapper(key)))
: 0 < handler.deps &&
((key = new ReactPromise("blocked", null, null, response)),
(handler.value = value),
(handler.chunk = key),
(value = Object.freeze.bind(Object, value.props)),
key.then(value, value),
(value = createLazyChunkWrapper(key)));
} else Object.freeze(value.props);
return value;
};
response._debugInfo = [value];
response = createLazyChunkWrapper(response);
break b;
}
if (0 < i.deps) {
response = new ReactPromise("blocked", null, null, response);
i.value = value;
i.chunk = response;
value = Object.freeze.bind(Object, value.props);
response.then(value, value);
response = createLazyChunkWrapper(response);
break b;
}
} else Object.freeze(value.props);
response = value;
}
else response = value;
return response;
}
return value;
};
}
for (i in value)
"__proto__" === i
? delete value[i]
: ((parentObject = reviveModel(response, value[i], value, i)),
void 0 !== parentObject
? (value[i] = parentObject)
: delete value[i]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Demonstrates the strict-mode accessor write and inherited-key hazards.
node <<'NODE'
"use strict";

function installOmittedAccessor(parentObject, key) {
  Object.defineProperty(parentObject, key, {
    get: function () {
      return "omitted";
    },
    enumerable: true,
    configurable: false
  });
  return null;
}

const payload = { omitted: "$Y" };
try {
  payload.omitted = installOmittedAccessor(payload, "omitted");
  console.log("assignment did not throw");
} catch (error) {
  console.log("strict accessor assignment:", error.constructor.name);
}

Object.prototype.__rscInherited = "$undefined";
const model = JSON.parse('{"own":1}');
const keys = [];
for (const key in model) keys.push(key);
delete Object.prototype.__rscInherited;

console.log("for-in keys:", keys.join(","));
NODE

Repository: shakacode/react_on_rails_rsc

Length of output: 141


🏁 Script executed:

fd -t f "react-server-dom-webpack-client.browser.development.js"

Repository: shakacode/react_on_rails_rsc

Length of output: 159


🏁 Script executed:

# Check file size first
wc -l src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js

Repository: shakacode/react_on_rails_rsc

Length of output: 164


🏁 Script executed:

# Read the section mentioned in the review comment (lines 2131-2203)
sed -n '2131,2203p' src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js

Repository: shakacode/react_on_rails_rsc

Length of output: 2720


🏁 Script executed:

# Check for "use strict" at the beginning of the file
head -20 src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js

Repository: shakacode/react_on_rails_rsc

Length of output: 632


🏁 Script executed:

# Find the parseModelString function to see the "$Y" case
rg -n "parseModelString" src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js | head -20

Repository: shakacode/react_on_rails_rsc

Length of output: 214


🏁 Script executed:

# Read the parseModelString function (starting at line 1352)
sed -n '1352,1500p' src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js

Repository: shakacode/react_on_rails_rsc

Length of output: 4741


🏁 Script executed:

# Find the reviveModel function
rg -n "function reviveModel" src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js

Repository: shakacode/react_on_rails_rsc

Length of output: 139


🏁 Script executed:

# Read the reviveModel function (starting at line 2125)
sed -n '2125,2210p' src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js

Repository: shakacode/react_on_rails_rsc

Length of output: 3158


Prevent strict-mode TypeError when reviveModel assigns to a $Y accessor property.

When parseModelString("$Y") executes, it defines a non-configurable getter-only accessor on parentObject[key], then returns null. The reviveModel function subsequently attempts to assign back to that key at line 2133 (array case) and lines 2200–2203 (object case). In strict mode, assigning to a property with a getter but no setter throws TypeError. Additionally, the object iteration loop at line 2196 uses for...in without filtering inherited properties, which can traverse prototype chain properties not present in the original object.

Guard revived property assignments and use own-property-only iteration:

Suggested fix
     function parseModel(response, json) {
       json = JSON.parse(json);
       return reviveModel(response, json, { "": json }, "");
     }
+    function setRevivedProperty(target, key, revivedValue) {
+      var descriptor = Object.getOwnPropertyDescriptor(target, key);
+      if (!descriptor || descriptor.writable || descriptor.set)
+        target[key] = revivedValue;
+    }
     function reviveModel(response, value, parentObject, key) {
       if ("string" === typeof value)
         return "$" === value[0]
           ? parseModelString(response, parentObject, key, value)
@@
       if ("object" !== typeof value || null === value) return value;
       if (isArrayImpl(value)) {
         for (var i = 0; i < value.length; i++)
-          value[i] = reviveModel(response, value[i], value, "" + i);
+          setRevivedProperty(
+            value,
+            "" + i,
+            reviveModel(response, value[i], value, "" + i)
+          );
@@
-      for (i in value)
-        "__proto__" === i
-          ? delete value[i]
-          : ((parentObject = reviveModel(response, value[i], value, i)),
-            void 0 !== parentObject
-              ? (value[i] = parentObject)
-              : delete value[i]);
+      for (i in value)
+        if (hasOwnProperty.call(value, i))
+          "__proto__" === i
+            ? delete value[i]
+            : ((parentObject = reviveModel(response, value[i], value, i)),
+              void 0 !== parentObject
+                ? setRevivedProperty(value, i, parentObject)
+                : delete value[i]);

Comment on lines +1660 to +1664
for (i in value)
"__proto__" === i
? delete value[i]
: ((parentObject = reviveModel(response, value[i], value, i)),
void 0 !== parentObject ? (value[i] = parentObject) : delete value[i]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Demonstrates why `for...in` is not equivalent to JSON reviver/object-own traversal.
node <<'NODE'
"use strict";

Object.prototype.__rscInherited = "$undefined";
const model = JSON.parse('{"own":1}');
const keys = [];
for (const key in model) keys.push(key);
delete Object.prototype.__rscInherited;

console.log(keys.join(","));
// Current hazard: "own,__rscInherited"
// Expected after own-property guard: "own"
NODE

Repository: shakacode/react_on_rails_rsc

Length of output: 90


🏁 Script executed:

find . -name "react-server-dom-webpack-client.node.production.js" -type f

Repository: shakacode/react_on_rails_rsc

Length of output: 157


🏁 Script executed:

sed -n '1655,1670p' ./src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js

Repository: shakacode/react_on_rails_rsc

Length of output: 602


🏁 Script executed:

sed -n '1630,1670p' ./src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js

Repository: shakacode/react_on_rails_rsc

Length of output: 1432


🏁 Script executed:

sed -n '1600,1670p' ./src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js

Repository: shakacode/react_on_rails_rsc

Length of output: 2467


🏁 Script executed:

sed -n '1, 30p' ./src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js

Repository: shakacode/react_on_rails_rsc

Length of output: 1104


🏁 Script executed:

node <<'NODE'
"use strict";

// Simulate the vulnerable code pattern from the file
const hasOwnProperty = Object.prototype.hasOwnProperty;

function vulnerableRevive(value) {
  let result = {};
  for (let i in value) {
    if ("__proto__" === i) {
      delete value[i];
    } else {
      result[i] = value[i];
    }
  }
  return result;
}

function protectedRevive(value) {
  let result = {};
  for (let i in value) {
    if (hasOwnProperty.call(value, i)) {
      if ("__proto__" === i) {
        delete value[i];
      } else {
        result[i] = value[i];
      }
    }
  }
  return result;
}

// Test: pollute Object.prototype
Object.prototype.__polluted = "evil";
const cleanJson = JSON.parse('{"own":"data"}');

console.log("Vulnerable revive picks up inherited:", Object.keys(vulnerableRevive(cleanJson)).length > 1);
console.log("Protected revive ignores inherited:", Object.keys(protectedRevive(cleanJson)).length === 1);

delete Object.prototype.__polluted;
NODE

Repository: shakacode/react_on_rails_rsc

Length of output: 155


Add hasOwnProperty guard to prevent inherited property enumeration.

Line 1660 uses for...in to traverse object properties, which enumerates inherited enumerable properties from the prototype chain. The previous JSON.parse reviver behavior only walked own properties. If Object.prototype is polluted before this code runs, those inherited keys will be copied into every revived model object.

Suggested guard
-  for (i in value)
-    "__proto__" === i
-      ? delete value[i]
-      : ((parentObject = reviveModel(response, value[i], value, i)),
-        void 0 !== parentObject ? (value[i] = parentObject) : delete value[i]);
+  for (i in value)
+    if (hasOwnProperty.call(value, i))
+      "__proto__" === i
+        ? delete value[i]
+        : ((parentObject = reviveModel(response, value[i], value, i)),
+          void 0 !== parentObject
+            ? (value[i] = parentObject)
+            : delete value[i]);

The hasOwnProperty variable is already available at the module level and the guard is straightforward to apply.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (i in value)
"__proto__" === i
? delete value[i]
: ((parentObject = reviveModel(response, value[i], value, i)),
void 0 !== parentObject ? (value[i] = parentObject) : delete value[i]);
for (i in value)
if (hasOwnProperty.call(value, i))
"__proto__" === i
? delete value[i]
: ((parentObject = reviveModel(response, value[i], value, i)),
void 0 !== parentObject
? (value[i] = parentObject)
: delete value[i]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js`
around lines 1660 - 1664, The loop that iterates properties in reviveModel
currently uses "for (i in value)" which can include inherited properties; update
that loop to skip non-own properties by checking hasOwnProperty.call(value, i)
before processing each key (use the existing module-level hasOwnProperty).
Ensure the existing logic (skipping "__proto__", calling reviveModel and
assigning or deleting parentObject) only runs when hasOwnProperty.call(value, i)
is true, so inherited prototype properties are not revived into the model.

… payload

Rebuild react-server-dom-webpack from React fork rsc-patches/v19.0.3
with the reviveModel optimization (backport of facebook/react#35776).

Replaces JSON.parse reviver callback with plain JSON.parse() followed
by a recursive reviveModel() walk in pure JS, yielding ~75% speedup
in RSC chunk deserialization by eliminating C++→JS boundary crossings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@AbanoubGhadban AbanoubGhadban force-pushed the perf/rsc-revive-model-walk branch from aefcbd7 to 67b27f0 Compare April 18, 2026 16:35
@claude
Copy link
Copy Markdown

claude Bot commented Apr 18, 2026

Code Review

Overview

This PR backports facebook/react#35776, replacing JSON.parse(json, reviver) with a two-step approach (JSON.parse + recursive reviveModel walk) to achieve ~75% speedup in RSC chunk deserialization. It also fixes a bug in the webpack plugin's chunk file selection logic.

The optimization is legitimate and well-documented. A few issues are worth addressing before merge:


Bug: Redundant duplicate check in reviveModel (all 8 client files)

In every compiled client file, the array branch of reviveModel contains an always-true inner condition that makes the else branch dead code:

if (isArrayImpl(value)) {
  for (var i = 0; i < value.length; i++)
    value[i] = reviveModel(response, value[i], value, "" + i);
  if (value[0] === REACT_ELEMENT_TYPE) {       // outer guard
    if (value[0] === REACT_ELEMENT_TYPE)       // ← always true here
      b: { ... }
    else response = value;                     // ← dead code, never reached
    return response;
  }
  return value;
}

The else response = value branch can never execute because the inner condition is identical to the outer guard. This appears to be a compilation artifact. Functionally the code is correct (the element is always returned via the b: block), but the dead else branch is misleading. The outer if check is redundant and should be removed so the labeled block applies directly, making the else meaningful.

Affected files: all 8 react-server-dom-webpack-client.*.js variants.


Positive: Plugin bugfix is correct

The old plugin code used sequential break statements that would stop iterating on the first non-JS file, meaning a CSS/asset chunk appearing before a JS chunk would silently drop the JS chunk from the manifest. The new if (...) { push; break; } pattern correctly skips non-JS files. This is a meaningful correctness fix.


Positive: __proto__ pollution protection in object walk

The new reviveModel object-walk explicitly guards against prototype pollution:

"__proto__" === i ? delete value[i] : ...

This is a good defensive addition over the old reviver approach.


Concern: Compiled-only diff, no source

The PR modifies vendored compiled output without including the upstream source changes from the React fork. This makes it impossible to verify the compiled output faithfully represents the source intent, or catch compilation artifacts like the duplicate check above. Consider linking to the exact source diff on the fork branch.


Missing automated test coverage

The test plan in the PR description is manual and unchecked. No automated tests are added. Given this touches the core deserialization path for every RSC payload, at minimum a regression integration test (render a nested client/server component tree through the full RSC pipeline) should be added.


Minor: set: function () {} in dev builds

The empty setter added to the "Y" case property descriptor silently swallows assignment attempts on the omitted-object placeholder. This prevents a TypeError in strict mode when something tries to assign to the property. Intentional and correct, but worth confirming against the upstream source since configurable: !1 without a setter would normally throw on assignment.

Comment on lines +1461 to +1462
if (value[0] === REACT_ELEMENT_TYPE) {
if (value[0] === REACT_ELEMENT_TYPE)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outer if (value[0] === REACT_ELEMENT_TYPE) on line 1461 and this inner check are identical. Once the outer guard passes, this inner condition is always true, making the else response = value on line 1489 dead code that can never execute.

The correct structure (matching the upstream intent) should collapse these into a single check with the labeled block applied directly:

Suggested change
if (value[0] === REACT_ELEMENT_TYPE) {
if (value[0] === REACT_ELEMENT_TYPE)
if (value[0] === REACT_ELEMENT_TYPE)

And remove the outer if (value[0] === REACT_ELEMENT_TYPE) { wrapper (line 1461) so the else response = value; branch is actually reachable for non-React-element arrays. The same fix applies to all 8 react-server-dom-webpack-client.*.js files.

Comment on lines 267 to +276
try {
for (_iterator.s(); !(_step = _iterator.n()).done; ) {
const file = _step.value;
if (!file.endsWith(".js")) break;
if (file.endsWith(".hot-update.js")) break;
chunks.push(c.id, file);
break;
if (
file.endsWith(".js") &&
!file.endsWith(".hot-update.js")
) {
chunks.push(c.id, file);
break;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix. The old code had three sequential break statements — the first two exited the loop early on non-JS or hot-update files, which meant a chunk whose file list started with a CSS/asset file would never have its JS file pushed to the manifest. The new condition correctly skips non-matching files and only breaks after a successful push.

get: function () {
return "This object has been omitted by React in the console log to avoid sending too much data from the server. Try logging smaller or more specific objects.";
},
set: function () {},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty setter prevents a TypeError in strict mode when consuming code (e.g., a serializer or proxy) tries to assign to the omitted-object placeholder property. Without it, configurable: false + no setter = silent failure in sloppy mode but a throw in strict mode. Intentional and correct — just confirm this matches the upstream source in the React fork.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload

1 participant